package client;

import client.game.Game;
import client.io.UserIO;
import client.net.io.ServerConnection;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import shared.client.io.ClientCommand;
import shared.net.protocol.ServerHeader;
import shared.util.Pair;

/**
 * This class represents a Nim client object.
 *
 * @author Vincent Tsuei
 * @author Andrew Gratta
 * @version 2012-11-21
 */
public class Client implements Runnable {

    // User IO
    private UserIO io;
    private Thread ioThread;
    
    // Network IO
    private ServerConnection connection;
    private ClientIOStore store;
    
    // Handler
    private ClientPacketProcessor processor;
    private Thread handlerThread;
    
    // User Command Handler
    private ClientCommandHandler commandHandler;
    
    // The following fields allow the client to send a packet, then wait for a response from
    // the server. While waiting for a response, the client's input remains non-responsive,
    // which is the preferred behavior in some situations (I.E. when waiting for an opponent to make
    // a move).
    //
    // Access to these fields should be done AFTER acquiring
    // the appropriate waitResponseLock lock.
    private ByteBuffer waitResponse = null;
    private ServerHeader.Send waitResponseHeader = null;
    private Object waitResponseNotify = null;
    private ReentrantReadWriteLock waitResponseLock = new ReentrantReadWriteLock();
    
    // Client State Information
    private String userName;
    private ClientState state = ClientState.IN_LOBBY;
    private Game game;
    private boolean expectUnobserve = false;

    public Client() {
    }

    @Override
    public void run() {

        // Store the IO Thread - So we can interrupt it if needed.
        ioThread = Thread.currentThread();

        // Setup the store.
        store = new ClientIOStore(this);

        // Setup command handler.
        commandHandler = new ClientCommandHandler(this);

        // Setup the handler/packet processor
        processor = new ClientPacketProcessor(this);
        handlerThread = new Thread(processor, "PacketProcessingThread");
        handlerThread.start();

        // Setup the user IO
        io = new UserIO(this);
        io.write("=== Welcome to the Game of Nim client ===", true);

        // Prompt for a server address and port
        InetSocketAddress SocketAddr = PromptForServerAddress();

        // Set up the connection - it is run in a different thread.
        // Wait until the connection is established.
        // If successful, continue.
        // If not successful, give the user the option to enter new server/port information,
        //    retry with the same information, or quit the program.
        connection = new ServerConnection(SocketAddr, this);
        boolean retry = true;
        while (retry) {
            try {
                new Thread(connection, "ServerConnectionThread").start();
            } catch (Exception ex) {
                io.write("An exception occurred while connecting: " + ex.getMessage(), true);
            }
            synchronized (this) { //Wait for the server connection to start.
                try {
                    this.wait();
                } catch (InterruptedException ex) {
                    Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
                }
            }
            if (connection.isConnected()) {
                retry = false;
            } else {
                Boolean promptRetry = null;
                try {
                    promptRetry = io.promptYesNo("Retry with same information [Y/N/(Q)uit]", "Y", true);
                } catch (InterruptedException ex) {
                    //Can never happen.
                    Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
                }
                if (promptRetry == null) { //Q
                    io.write("Exiting ...", true);
                    System.exit(0);
                } else if (promptRetry) { //Y
                    connection.reset(); 
                } else {                  //N
                    connection = null;
                    SocketAddr = PromptForServerAddress();
                    connection = new ServerConnection(SocketAddr, this);
                }
            }
        }

        //This is the main User IO loop.
        //It presents a menu with options.
        boolean exit = false;
        boolean firstMenu = true;
        while (!exit) {
            Pair<ClientCommand, String[]> command;
            try {
                // Prompt for a menu option
                command = io.PromptMenu(firstMenu);
            } catch (InterruptedException ex) {

                //Special interrupt Logic.
                // This interrupt is used by the GameStartHandler and 
                // play command force the User IO thread to stop waiting on 
                // input and run the game.
                if (this.isPlaying()) {
                    Thread.interrupted(); //Clear interrupt flag.
                    io.unsetInterrupt();
                    if (game.isGameOver()) {
                        System.out.println("The game has ended.");
                        deregisterGame();
                    } else {
                        try {
                            game.run();
                        } catch (InterruptedException ex1) {
                            Thread.interrupted(); //Clear interrupt flag.
                            io.unsetInterrupt();
                            System.out.println("The game has ended.");
                            deregisterGame();
                        }
                    }
                }
                continue;
            }
            
            //Only display at the very beginning of the program. - Less waste of space, and less jarring.
            firstMenu = false; 

            //HELP is special. Handle it here.
            if (command.getLeft() == ClientCommand.HELP) {
                firstMenu = true;
            } else {
                
                //Process the command.
                //The result is unused as of yet.
                Pair<Integer, String> result = commandHandler.handleCommand(command.getLeft(), command.getRight());
            }
        }
    }

    /**
     * Get the command handler associated with this client.
     */
    public ClientCommandHandler getCommandHandler() {
        return commandHandler;
    }

    /**
     * Utility function - Prompt for a server IP address and port number.
     * Returns the InetSocketAddress created from the given IP and port.
     */
    private InetSocketAddress PromptForServerAddress() {
        String ServerIP = null;
        try {
            ServerIP = io.promptString("Please enter a server to connect to", "127.0.0.1");
        } catch (InterruptedException ex) {
            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
        }
        System.out.println("    Selected Server: " + ServerIP);
        Integer ServerPort = null;
        try {
            ServerPort = io.promptInteger("Please the port number to connect to", "62222");
        } catch (InterruptedException ex) {
            Logger.getLogger(Client.class.getName()).log(Level.SEVERE, null, ex);
        }
        System.out.println("    Selected Port: " + ServerPort);
        return new InetSocketAddress(ServerIP, ServerPort);
    }

    /**
     * Notify the client object that the connection has shutdown.
     * When this happens, we exit the client.
     */
    public void notifyConnectionShutdown() {
        if (connection.isShutdown()) { //Check to make sure the connection really is shutdown.
            connection = null;

            //TODO: Fill in. -- Probably will have to interrupt the run() method.
            System.out.println("Connection closed.");
            System.exit(0); //Generic close -- TODO - Do this properly.
        }
    }

    /**
     * Get the server connection for this client.
     */
    public ServerConnection getServerConnection() {
        return connection;
    }

    /**
     * Add a packet to the output queue. *** Asynchronous ***
     */
    public void queuePacketForWriteAsync(byte[] packet) {
        store.queueOutputPacketAsync(packet);
    }

    /**
     * Write the packet. *** Synchronous *** The current thread waits until the
     * packet is sent.
     */
    public void queuePacketForWriteSync(byte[] packet) throws InterruptedException {
        synchronized (packet) {
            store.queueOutputPacketSync(packet); //Needs to be inside the sync.
            packet.wait();
        }
    }

    /**
     * Add a packet to the input/processing queue.
     */
    public void queuePacketForProcessing(ByteBuffer packet) {
        store.queueInputPacket(packet);
    }

    /**
     * Get the next packet in the output queue.
     */
    public Pair<byte[], Boolean> getNextOutputPacket() throws InterruptedException {
        return store.getNextOutputPacket();
    }

    /**
     * Get the next packet in the processing queue.
     */
    public ByteBuffer getNextInputPacket() throws InterruptedException {
        return store.getNextInputPacket();
    }

    /**
     * Get the user's IO manager
     *
     * @return
     */
    public UserIO getUserIO() {
        return io;
    }

    /**
     * Write a packet then, make the current thread wait for a packet with the
     * specified header.
     */
    public void sendPacketAndWaitForResponse(byte[] b, Object toNotify, ServerHeader.Send header) throws InterruptedException {
        if (header == null || toNotify == null || b == null) {
            throw new RuntimeException(); //Nothing can be null.
        }
        //First, set-up the response infrastructure.
        waitResponseLock.writeLock().lock();
        try {
            if (waitResponseHeader != null) {
                throw new RuntimeException("Client is already waiting for something! " + waitResponseHeader);
            }
            waitResponseHeader = header;
            waitResponseNotify = toNotify;

            //Now write the actual packet - Do a synchronous call.
            queuePacketForWriteSync(b);
        } finally {
            waitResponseLock.writeLock().unlock();
        }

        //Now we wait, lalala
        synchronized (toNotify) {
            toNotify.wait();
        }
        
        io.drain();
    }

    /**
     * Make the current thread wait for a packet with the specified header.
     */
    public void waitForPacket(Object toNotify, ServerHeader.Send header) throws InterruptedException {
        if (header == null || toNotify == null) {
            throw new RuntimeException(); //Nothing can be null.
        }
        //First, set-up the response infrastructure.
        waitResponseLock.writeLock().lock();
        try {
            if (waitResponseHeader != null) {
                throw new RuntimeException("Client is already waiting for something!");
            }
            waitResponseHeader = header;
            waitResponseNotify = toNotify;
        } finally {
            waitResponseLock.writeLock().unlock();
        }
        //Now we wait, lalala
        synchronized (toNotify) {
            toNotify.wait();
        }
        io.drain();
    }

    /**
     * Return whether the client is waiting for a packet from the server.
     */
    public boolean isWaitingForResponse() {
        waitResponseLock.readLock().lock();
        try {
            return waitResponseHeader != null;
        } finally {
            waitResponseLock.readLock().unlock();
        }
    }

    /**
     * Get the header of the packet we are waiting for.
     */
    public ServerHeader.Send getWaitResponseHeader() {
        waitResponseLock.readLock().lock();
        try {
            return waitResponseHeader;
        } finally {
            waitResponseLock.readLock().unlock();
        }
    }

    /**
     * Notify the client that a received packet is the response the client is
     * waiting for.
     *
     * Throws RuntimeException is ByteBuffer is null or client is not waiting
     * for a response. ** IMPORTANT: WE DO NOT VALIDATE THE PACKET. DO THIS
     * YOURSELF WITH getWaitResponseHeader() ***
     */
    public void notifyResponse(ByteBuffer b) throws InterruptedException {
        if (b == null) {
            throw new RuntimeException("null notifyResponse() packet");
        }
        waitResponseLock.writeLock().lock();
        try {
            if (waitResponseHeader == null) {
                throw new RuntimeException("notifyResponse() called, but client not waiting for response from server.");
            }
            waitResponseHeader = null;

            //We need to wait until the last waitResponse has been processed...
            if (waitResponse != null) {
                synchronized (waitResponse) { //Necessary sync.
                    if (waitResponse != null) {
                        waitResponse.wait();
                    }
                }
            }

            //Now write the waitResponse and notify all waiting threads (should only be one).
            waitResponse = b;
            synchronized (waitResponseNotify) {
                waitResponseNotify.notifyAll();
            }
        } finally {
            waitResponseLock.writeLock().unlock();
        }
    }

    /**
     * Returns the waitRespones ByteBuffer if any exists. NOTE: This response is
     * then deleted.
     */
    public ByteBuffer retrieveWaitResponse() {
        synchronized (waitResponse) {
            if (waitResponse != null) {
                ByteBuffer ret = null;
                try {
                    ret = waitResponse;
                    waitResponse = null;
                    return ret;
                } finally {
                    ret.notifyAll();
                }
            }
        }
        return null;
    }

    /**
     * Set the user name of this client.
     */
    public void setLogin(String name) {
        if (userName == null) {
            this.userName = name;
        } else {
            throw new RuntimeException("Trying to login as " + name + " when already logged in as " + userName);
        }
    }

    /**
     * Return whether the user is logged in (I.E. has a username)
     */
    public boolean isLoggedIn() {
        return userName != null;
    }

    /**
     * Return the username of the client.
     * null if the client has not logged in yet.
     */
    public String getUserName() {
        return userName;
    }

    /**
     * Register a game with the client.
     * This signals that the client is playing that game.
     */
    public void registerGame(Game g) {
        if (this.game == null) {
            this.game = g;
            this.state = ClientState.PLAYING;
        } else {
            throw new RuntimeException("Game already registered.");
        }
    }

    /**
     * Deregister the game with the client.
     */
    public void deregisterGame() {
        this.game = null;
        if (this.getWaitResponseHeader() == ServerHeader.Send.GAME_UPDATE
                || this.getWaitResponseHeader() == ServerHeader.Send.OBSERVE_GAME_UPDATE) {
            removeWaitForPacketFlag();
        }
        setLobby();
    }
    
    /**
     * Put the client in the lobby.
     */
    public void setLobby() {
        this.state = ClientState.IN_LOBBY;
    }
    
    /**
     * Put the client in observer state.
     */
    public void setObserve() {
        if (state == ClientState.IN_LOBBY) {
            state = ClientState.OBSERVING;
        } else {
            throw new RuntimeException("Trying to observe, but not in lobby.");
        }
    }

    /**
     * Return whether the client is currently in a game.
     */
    public boolean isPlaying() {
        return state == ClientState.PLAYING && game != null;
    }

    /**
     * Return whether the client is currently observing a game.
     */
    public boolean isObserving() {
        return state == ClientState.OBSERVING;
    }
    
    /**
     * Return whether the client is currently in the lobby.
     */
    public boolean isInLobby() {
        return state == ClientState.IN_LOBBY;
    }

    /**
     * Return the game associated with the client
     */
    public Game getGame() {
        return game;
    }

    /**
     * Interrupt the IO thread.
     *
     * -- Note: Doesn't actually call thread.interrupt() Java IO input streams
     * seem to ignore the interrupt, so we'll hack our way around it - FIXED
     */
    public void interruptIOThread(boolean clientsTurn) {
        io.setInterrupt(clientsTurn);
        ioThread.interrupt();
    }

    /**
     * Reset the waiting for packet flag on the client.
     * This is useful if we have interrupted the UserIO
     * while it was waiting for a packet.
     */
    public void removeWaitForPacketFlag() {
        waitResponseLock.writeLock().lock();
        try {
            waitResponseHeader = null;
            waitResponseNotify = null;
        } finally {
            waitResponseLock.writeLock().unlock();
        }
    }
    
    /**
     * Returns whether the client is waiting for an
     * ObserveGameEnd packet.
     */
    public boolean getExpectUnobserve() {
        return this.expectUnobserve;
    }
    
    /**
     * Tell the client to expect an ObserveGameEnd packet
     * (this is when the game ends naturally).
     */
    public void setExpectUnobserve() {
        this.expectUnobserve = true;
    }
    
    /**
     * Tell the client to clear the ExpectUnobserve flag.
     */
    public void setNotExpectUnobserve() {
        this.expectUnobserve = false;
    }
}
